2023년 2월 16일
제 개인적인 의견이지만, 누군가가 프론트의 중요한 역할이 무엇이냐고 물어본다면 가장 심플하게 데이터를 잘 받아와, 유저에게 잘 보여주는 것 이라고 대답할것입니다.
물론 그것이 전부는 아니겠지만, 일반적인 웹&앱 서비스에서 가장 많은 부분을 차지하는것은 데이터를 보여주는 일입니다.
그래서 우리는 잘 보여주기 위해 리액트나 뷰, 스벨트 등의 프레임워크를 사용하곤 합니다. DOM을 더 쉽고 간단하게 컨트롤해서 유저에게 더 좋은 UI를 보여주기 위함이죠.
하지만 그만큼 중요한것이 데이터를 잘 받아오는 일입니다. 아마 fetch 이벤트나 대부분의 경우 axios를 사용하여 백엔드와 통신을 할 것입니다. 그리고 대부분의 경우 내부 state 내지 전역상태관리 라이브러리를 이용하여 백엔드에서 받은 데이터를 보여줄 것입니다.
이 과정을 좀 더 쉽고 간단하게 바꿔줄 리액트 쿼리에 대해 알아보겠습니다.
대부분 기존 상태관리 라이브러리는 내부 state관리에는 용이하나, 서버 상태관리에는 어려움이 있었습니다.
이러한 문제들을 해결하기 위해 리액트 쿼리가 개발되었습니다.
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
// 서버와 통신하기 위한 fetch 및 axios api 모듈
import { getTodos, postTodo } from '../my-api'
// 쿼리 클라이언트를 생성합니다.
const queryClient = new QueryClient()
function App() {
return (
// 쿼리 프로바이더로 APP을 감싸줍니다.
// 해당 context 는 비동기를 처리하는 background 계층이 됩니다.
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
function Todos() {
// APP내에서 쿼리클라이언트를 사용할수 있습니다.
const queryClient = useQueryClient()
// userQuert = R
const { isLoading, isError, data, error } = useQuery({
queryKey: ['todos'],
queryFn: getTodos,
})
// useMutations = C,U,D
const mutation = useMutation({
mutationFn: postTodo,
onSuccess: () => {
// 객체가 성공한다면..
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<div>
<ul>
{data?.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button
onClick={() => {
mutation.mutate({
id: Date.now(),
title: 'Do Laundry',
})
}}
>
Add Todo
</button>
</div>
)
}
리액트 쿼리로 가져온 쿼리데이터는 다음과 같은 라이프 사이클을 가지고 있습니다.
fetching => fresh => stale => inactive => delete
각각에 대한 상세내용은 다음과 같습니다.
const { isLoading, data, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
useQuery(['todos', todoId], () => fetchTodoById(todoId))
// 이런식으로도 표현 가능하다
useQuery(['todos', todoId], async () => {
const data = await fetchTodoById(todoId)
return data
})
const mutation = useMutation(newTodo => axios.post('/todos', newTodo))
// setQueryData를 통한 데이터 갱신
export const addTodos = () => {
const queryClient = useQueryClient()
return useMutation(fetchAddSuperHero, {
onSuccess: data => {
queryClient.setQueryData('todos', prevData => ({
...prevData,
data: [...prevData.data, data.data],
}))
},
})
}
// 다음 쿼리들의 쿼리키는 모두 동일합니다.
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
// 다음 쿼리들의 쿼리키는 모두 동일하지 않습니다.
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})
기본적으로 캐싱된 쿼리는 상태가 stale하지 않으면 리패칭을 진행하지 않습니다. 쿼리의 상태가 stale로 변화하는 시간이 StaleTime이며 default값은 0입니다.
stale한 쿼리는 다음의 경우 리패칭을 시도합니다.
stlae 쿼리 인스턴스가 마운트되었을 때
브라우저 윈도우가 다시 포커스되었을 때 (탭이나 윈도우 이동)
네트워크가 다시 연결되었을 때
refetchInterval 옵션이 있을 때
하나의 컴포넌트에서 2개이상의 쿼리를 실행시킬 경우 특별한 경우가 아니라면 병렬(공식문서에서 병렬이라 칭함)적으로 실행될 것입니다.
function App () {
// 병렬로 실행되는 쿼리들
const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
const todoQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTodo })
...
}
// useQueries를 사용할수도 있습니다.
const res = useQueries([
{
queryKey: ['users'],
queryFn: () => {},
},
{
queryKey: ['teams'],
queryFn: () => {},
}
]);
비동기함수를 체이닝하여 선,후행으로 사용해야 할 경우가 있을것입니다. 그럴 경우에 사용하는것이 종속쿼리입니다.
// 유저 정보를 가져옵니다
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: getUser,
})
const userId = user?.id
// 종속쿼리 user쿼리가 요청에 성공하여 userId가 반환될 경우 실행됩니다.
// 이 경우 promise.all과 같이 하나의 배열에 각 객체값이 들어옵니다.
const { status, fetchStatus, data: todos } = useQuery({
queryKey: ['todos', userId],
queryFn: getTodos,
// 유저아이디가 있을경우 해당 속성이 변경됩니다
enabled: !!userId,
})
전체 쿼리 클라이언트에 대한 성공/실패 분기처리를 할 수 있습니다.
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
console.log(error, query);
if (query.state.data !== undefined) {
toast.error(`에러가 났어요!!: ${error.message}`);
},
},
onSuccess: data => {
console.log(data)
}
})
});
우선적으로 리액트 쿼리는 전역 상태관리를 위한 라이브러리가 아닌점을 기본개념으로 가져가야 합니다. 대부분 많은 경우 Redux와 같은 전역 상태관리 라이브러리와 많이 비교하지만, 실질적으로는 개념이 다르며 동시에 사용하는 경우도 있습니다.
다만 Redux thunk를 이용한 API 비동기 처리와 그 이후 데이터 핸들링을 주 목적으로 Redux를 사용하고 있었다면, 아주 좋은 대안으로 리액트 쿼리를 사용할 수 있습니다.
// User.jsx
const [loading, setLoading] = useState(false)
const setUser = async () => {
try {
changeToken(token)
const user = await dispatch(getUsersMeThunk()).unwrap()
} catch (err) {
throw new Error(err)
}
}
useEffect(() => {
setUser()
}, [])
return <>{loading && <div>로그인 성공!</div>}</>
// Redux thunk middleware
export const getUsersThunk = createAsyncThunk('user/getUserState', async () => {
try {
const { data } = await get_user()
return data
} catch (err) {
throw new Error(err)
}
})
export const userSlice = createSlice({
name: 'User Info',
initialState: {
info: {
birthdate: '',
email: '',
gender: '',
id: 0,
},
},
reducers: {},
extraReducers: {
[getUsersThunk.fulfilled.type]: (state, { payload }) => {
state.info = { ...payload }
},
},
})
const { isLoading, data, error } = useQuery({
queryKey: ['user'],
queryFn: get_user,
})
return <>{data && <div>로그인 성공!</div>}</>
다만 이경우는 단적인 예시일 뿐이며, 다양한 경우를 고려하여 잘 조합해 사용해야 합니다.